import logging
import os
from collections.abc import Callable, Mapping
from weakref import proxy, ProxyType
from abc import abstractmethod, ABC
from os import PathLike
from pathlib import Path
from tempfile import TemporaryDirectory
from traceback import format_exception
from typing import TypedDict, runtime_checkable, Protocol, Literal, Optional, Sequence, Self, Final, cast, ClassVar, overload
from zipfile import ZipFile

import arcpy
import attrs
import pandas as pd
from osgeo import ogr

from .accessor import NGUID
from .config_dataclasses import NG911FeatureClass, NG911Field
from .datachanges import StandardGeodatabaseCreation
from .iterablenamespace import FrozenList
from .misc import NGUIDAssignMethod as NAM, _AttrsInstanceWithAllowedFields, validate_field_exists, arc_field_details
from .session import config
from .topology import topology_config, TopologyRule

_logger = logging.getLogger(__name__)

_rds: Final[str] = config.gdb_info.required_dataset_name  # "[r]equired [d]ata[s]et"
_rdst: Final[str] = topology_config.required_dataset_topology_name  # "[r]equired [d]ata[s]et [t]opology"
_ods: Final[str] = config.gdb_info.optional_dataset_name  # "[o]ptional [d]ata[s]et"
_odst: Final[str] = topology_config.optional_dataset_topology_name  # "[o]ptional [d]ata[s]et [t]opology"


class NG911DataFrameAttrs(TypedDict):
    path: Path
    feature_class: NG911FeatureClass
    fields: tuple[NG911Field, ...]
    nguid_name: str
    oid_name: str | None
    shape_name: str
    geodatabase_path: Path


class DummyEditor:
    """Class to be instantiated as the value of :attr:`NG911Session.editor` for
    instances of that class not using an edit session. This will prevent method
    calls from failing as opposed to simply setting that attribute to
    ``None``."""

    def __init__(self, session: Optional["NG911Session"] = None):
        self._session: ProxyType["NG911Session"] | None
        if session:
            self._session = proxy(session)
        else:
            self._session = None

    def __enter__(self):
        pass

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type:
            raise exc_val

    @property
    def isEditing(self):
        return False

    @property
    def workspacePath(self):
        return self._session.gdb

    def abortOperation(self):
        pass

    def redoOperation(self):
        pass

    def startEditing(self, with_undo=True, multiuser_mode=True):
        pass

    def startOperation(self):
        pass

    def stopEditing(self, save_changes=True):
        pass

    def stopOperation(self):
        pass

    def undoOperation(self):
        pass



@runtime_checkable
class GPMessenger(Protocol):
    """Protocol declaring the methods of the ``messages`` parameter of
    geoprocessing tool class ``execute`` methods."""

    @abstractmethod
    def addMessage(self, message: str) -> None: ...

    @abstractmethod
    def addWarningMessage(self, message: str) -> None: ...

    @abstractmethod
    def addErrorMessage(self, message: str) -> None: ...

    @abstractmethod
    def addIDMessage(self, message_type: Literal["ERROR", "INFORMATIVE", "WARNING"], message_ID: int, add_argument1: Optional[str | int | float] = None, add_argument2: Optional[str | int | float] = None) -> None: ...

    @abstractmethod
    def addGPMessages(self) -> None: ...


class NG911Session(arcpy.EnvManager, os.PathLike[str]):
    _gdb: Path
    """Path to the NG911 geodatabase."""

    _is_active: bool
    """Whether the environment settings (including workspace) are in effect."""

    _respect_submit: bool
    """Whether to use ``SUBMIT='Y'`` in where clauses by default."""

    _use_edit_session: bool
    """Whether to utilize an ``arcpy.da.Editor`` instance."""

    messenger: GPMessenger
    """The geoprocessing messenger object typically passed to the ``execute``
    method of a geoprocessing tool (or an instance of
    :class:`FallbackGPMessenger`)."""

    editor: arcpy.da.Editor | DummyEditor
    """An instance of ``arcpy.da.Editor`` for managing edits when
    :attr:`_use_edit_session` is ``True``. Otherwise, a :class:`DummyEditor`
    instance is created, and calling methods on it will have no effect."""

    def __init__(self, workspace: PathLike[str] | str, respect_submit: bool, use_edit_session: bool = False, messenger: Optional[GPMessenger] = None, **env_kwargs):
        """
        Initializes an ``NG911Session`` instance. This class is a context
        manager and a subclass of ``arcpy.EnvManager``, and it is intended to
        be used in a ``with`` statement where *workspace* is the path to the
        geodatabase to be accessed. Furthermore, the ``maintainCurveSegments``
        geoprocessing environment is set to True by default.

        :param workspace: Path to the geodatabase
        :type workspace: PathLike[str] | str
        :param respect_submit: Whether to only analyze features where the
            ``SUBMIT`` attribute is ``"Y"``
        :type respect_submit: bool
        :param use_edit_session: Whether to automatically open and close an
            ArcPy edit session, default ``False``
        :type use_edit_session: bool
        :param messenger: ArcPy message-handling object; this is the
            ``messages`` argument passed to the ``execute`` method of a
            script-based geoprocessing tool. If ``None`` (the default), this is
            set to a fresh instance of :class:`FallbackGPMessenger`.
        :type messenger: GPMessenger
        :param env_kwargs: Additional arguments to be passed to the superclass
            (``arcpy.EnvManager``) initializer
        :type env_kwargs: Any
        """
        if arcpy.Describe(str(workspace)).dataType != "Workspace":
            raise ValueError(f"'{workspace}' is not a workspace.")
        self._gdb = Path(workspace)
        self._is_active = False
        self._respect_submit = respect_submit
        self._use_edit_session = use_edit_session
        self.editor = arcpy.da.Editor(workspace) if use_edit_session else DummyEditor(self)
        self.messenger = messenger or FallbackGPMessenger()
        super().__init__(workspace=str(workspace), maintainCurveSegments=True, **env_kwargs)
        _logger.info(f"Initialized {__class__.__name__} in workspace '{workspace}'.")

    def __enter__(self) -> Self:
        _logger.info(f"Entering {__class__.__name__} in workspace '{self._gdb}'.")
        super().__enter__()
        self._is_active = True
        # if self._use_edit_session:
        #     self.editor.startEditing()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        _logger.info(f"Exiting {__class__.__name__} in workspace '{self._gdb}'.")
        self._is_active = False
        # should_save_edits: bool | None = (
        #     None if not self.editor.isEditing  # Irrelevant if not editing
        #     else False if exc_type  # DON'T save edits if error
        #     else True  # Otherwise, DO save edits
        # )
        if exc_type:
            message: str = "".join([
                f"Uncaught {exc_type.__name__} within {__class__.__name__}:\n",
                f"\t{exc_type.__name__}: {exc_val}\n",
                *format_exception(exc_type, exc_val, exc_tb)
            ])
            self.messenger.addWarningMessage(message)
            _logger.critical(message)
            if self.editor.isEditing:
                self.editor.stopEditing(False)
                self.messenger.addWarningMessage("Due to the above issue, edits WERE NOT saved.")
                _logger.warning("Due to the above issue, edits WERE NOT saved.")
        elif self.editor.isEditing:  # Implies active edit session AND no exception raised
            self.editor.stopEditing(True)
            _logger.info("Saved edits.")
        return super().__exit__(exc_type, exc_val, exc_tb)

    @property
    def gdb_path(self) -> Path:
        """Returns a ``pathlib.Path`` instance representing the geodatabase
        directory."""
        return self._gdb

    @property
    def gdb(self) -> str:
        """
        This is essentially a shortcut for ``arcpy.env.workspace`` that also
        checks to ensure that the ``NG911Session`` instance is currently
        active (i.e. is being accessed within the ``with`` statement or is
        being accessed *between* calls to ``__enter__()`` and ``__exit__()``).

        :return: Path to the current workspace
        :rtype: str
        """
        if self._is_active:
            return self.__fspath__()
        else:
            raise RuntimeError("Validator is not active; either __enter__() has not been called, or __exit__() has already been called.")

    def __fspath__(self) -> str:
        return self._gdb.__fspath__()

    def __str__(self) -> str:
        return f"<{self.__class__.__name__} in {self.__fspath__()}>"

    def __repr__(self) -> str:
        if self.__class__ is __class__:
            kwargs = self._environments.copy()
            kwargs_repr = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items() if k != "workspace")
            if kwargs_repr:
                return f"{self.__class__.__name__}({repr(self._gdb)}, {self._respect_submit}, {kwargs_repr})"
            else:
                return f"{self.__class__.__name__}({repr(self._gdb)}, {self._respect_submit})"
        else:
            return super().__repr__()

    @property
    def respect_submit(self) -> bool:
        """Returns whether operations with an instance of this class are to
        only apply to those features where the ``SUBMIT`` attribute is equal to
        ``"Y"``."""
        return self._respect_submit

    @property
    def required_ds_exists(self) -> bool:
        """Returns whether the geodatabase contains a feature dataset with the
        prescribed name of the :term:`required feature dataset`."""
        return bool(arcpy.ListDatasets(_rds, "Feature"))

    @property
    def optional_ds_exists(self) -> bool:
        """Returns whether the geodatabase contains a feature dataset with the
        prescribed name of the :term:`optional feature dataset`."""
        return bool(arcpy.ListDatasets(_ods, "Feature"))

    @property
    def required_ds_topology_exists(self) -> bool:
        """Returns whether the :term:`required feature dataset` contains a
        topology with the expected name. Does not indicate whether it contains
        any rules or members."""
        with self.required_ds_env_manager:
            return bool(arcpy.ListDatasets(_rdst, "Topology"))

    @property
    def optional_ds_topology_exists(self) -> bool:
        """Returns whether the :term:`optional feature dataset` contains a
        topology with the expected name. Does not indicate whether it contains
        any rules or members."""
        with self.optional_ds_env_manager:
            return bool(arcpy.ListDatasets(_odst, "Topology"))

    @property
    def required_ds_env_manager(self) -> arcpy.EnvManager:
        """Shortcut to get an instance of ``arcpy.EnvManager`` with the
        workspace set to the :term:`required feature dataset` of the Validator
        instance's geodatabase and all other environment settings copied from
        the Validator instance."""
        envs = self._environments.copy()
        envs["workspace"] = str(self._gdb / config.gdb_info.required_dataset_name)
        return arcpy.EnvManager(**envs)

    @property
    def optional_ds_env_manager(self) -> arcpy.EnvManager:
        """Shortcut to get an instance of ``arcpy.EnvManager`` with the
        workspace set to the :term:`optional feature dataset` of the Validator
        instance's geodatabase and all other environment settings copied from
        the Validator instance."""
        envs = self._environments.copy()
        envs["workspace"] = str(self._gdb / config.gdb_info.optional_dataset_name)
        return arcpy.EnvManager(**envs)

    def count(self, feature_class: NG911FeatureClass, where_clause: Optional[str] = None) -> int:
        """Counts the number of features in a feature class using OGR, with an
        optional where clause. This is much faster than using ArcPy or Pandas."""
        ogr.UseExceptions()
        driver: ogr.Driver = ogr.GetDriverByName("OpenFileGDB")
        ds: ogr.DataSource = driver.Open(self.gdb)
        lyr: ogr.Layer = ds.GetLayerByName(feature_class.name)
        if where_clause:
            lyr.SetAttributeFilter(where_clause)
        return lyr.GetFeatureCount()

    def load_df(self, feature_class: NG911FeatureClass, fields: Optional[Sequence[NG911Field | Literal["OID@"] | str]] = None, respect_submit: Optional[bool] = None, **kwargs) -> pd.DataFrame:
        """
        Loads and returns a Spatially-Enabled ``DataFrame`` containing the data
        of *feature_class*.

        This method specifically sets the index of the ``DataFrame`` to the
        NGUID column, and it does so **without dropping the column** if the
        NGUID field is in *fields*. This provides for compatibility with
        :meth:`.validation.FeatureAttributeErrorMessage.from_df`.

        The resulting ``DataFrame``\ 's ``attrs`` dict will be an instance of
        :class:`NG911DataFrameAttrs`.

        .. note::

            The NGUID field will be loaded regardless of whether it was
            included in *fields*, but it will only be included as a regular
            column if it was included in *fields*.

        .. seealso::

            :class:`NG911DataFrameAttrs`

        :param feature_class: The feature class to load
        :type feature_class: NG911FeatureClass
        :param fields: The fields to include, as either :class:`NG911Field`
            objects or field names; the string ``OID@`` is also allowed **and
            will be replaced** with the name of the ``OBJECTID`` field
        :type fields: Optional[Sequence[NG911Field | Literal["OID@"] | str]]
        :param respect_submit: Whether to only return features where the
            :ng911field:`submit` attribute is ``"Y"``; defaults to
            :attr:`_respect_submit`
        :type respect_submit: Optional[bool]
        :param kwargs: Additional keyword arguments to pass to
            ``pandas.DataFrame.spatial.from_featureclass()``
        :type kwargs: Any
        :return: The feature class data in a ``pandas.DataFrame``
        :rtype: pandas.DataFrame
        """
        if not self._is_active:
            raise RuntimeError("Session is not active; either __enter__() has not been called, or __exit__() has already been called.")

        fields: Optional[list[NG911Field | Literal["OID@"] | str]] = [*fields] if fields is not None else None

        if respect_submit is None:
            respect_submit = self._respect_submit

        drop_nguid: bool = False
        """Whether to drop the NGUID column when setting it as the index. Only
        gets set to ``True`` if *fields* is provided and does not include the
        NGUID column."""

        fc_path: Path = self.gdb_path / feature_class.dataset / feature_class.name
        describe_data: dict = arcpy.da.Describe(str(fc_path))
        oid_name = describe_data["OIDFieldName"]
        if fields is not None and "OID@" in fields:
            fields[fields.index("OID@")] = oid_name

        if fields:
            field_names: list[str] = [f.name if isinstance(f, NG911Field) else f for f in fields]
            fields: list[NG911Field] = [config.get_field_by_name(f) for f in field_names if f in [_f.name for _f in config.fields.values()]]
            if feature_class.unique_id.name not in field_names:
                field_names.insert(0, feature_class.unique_id.name)
                drop_nguid = True
        else:
            field_names: None = None
            # If no fields were explicitly provided, try to get something to put in result.attrs["fields"]
            fields: list[NG911Field] = [config.get_field_by_name(f.name) for f in arcpy.ListFields(feature_class.name) if f in [_f.name for _f in config.fields.values()]]

        wc: str | None = f"{config.fields.submit.name} = 'Y'" if respect_submit else None
        if wc_arg := kwargs.pop("where_clause", None):
            # If a where_clause argument was already provided, combine it with ``wc`` if necessary
            wc = f"({wc}) AND ({wc_arg})" if wc else wc_arg

        result: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(
            str(fc_path),
            where_clause=wc,
            fields=field_names,
            **kwargs
        ).set_index(feature_class.unique_id.name, drop=drop_nguid)

        df_attrs: NG911DataFrameAttrs = {
            "path": fc_path,
            "feature_class": feature_class,
            "fields": tuple(fields),
            "nguid_name": feature_class.unique_id.name,
            "oid_name": oid_name if oid_name in result.columns else None,
            "shape_name": describe_data["shapeFieldName"],
            "geodatabase_path": self.gdb_path
        }
        result.attrs = df_attrs
        return result

    def path_to(self, item: NG911FeatureClass | str) -> Path:
        """
        Returns the absolute path to *item* in the geodatabase, where *item*
        is any of the following:

        - An NG911FeatureClass instance
        - A valid NG911FeatureClass role
        - The name of the required or optional feature dataset
        - The name of the required or optional dataset topology

        :param item: The geodatabase item for which a path will be returned
        :type item: NG911FeatureClass | str
        :return: Path to *item*
        :rtype: pathlib.Path
        """
        if isinstance(item, NG911FeatureClass):
            # item is an NG911FeatureClass
            return self._gdb.joinpath(item.dataset, item.name)
        elif isinstance(item, str):
            if item in config.feature_classes.keys():
                # item is a role
                return self.path_to(config.feature_classes[item])
            elif item in {*[fc.dataset for fc in config.feature_classes.values()], _rds, _ods}:
                # item is a feature dataset name
                return self._gdb.joinpath(item)
            elif item in {_rdst, _odst}:
                # item is a topology name
                ds: str = {_rdst: _rds, _odst: _ods}[item]
                return self._gdb.joinpath(ds, item)
            else:
                raise ValueError(f"Argument 'item' must be an NG911FeatureClass instance, feature class role, feature dataset name, or topology name; got '{item}'.")
        else:
            raise TypeError(f"Expected NG911FeatureClass or str, got '{type(item)}' ('{item}').")

    def str_path_to(self, item: NG911FeatureClass | str) -> str:
        """
        Returns the absolute path to *item* in the geodatabase as a ``str``.
        See :meth:`path_to`.

        :param item: The geodatabase item for which a path will be returned
        :type item: NG911FeatureClass | str
        :return: Path to *item* as a ``str``
        :rtype: str
        """
        return str(self.path_to(item))

    def _copy_to_zip(self, destination: Path | str, overwrite: bool = False, *, staged_gdb_path: Optional[Path] = None) -> Path:
        """
        Creates a .zip file with the contents of the geodatabase. Files with a
        name ending in ".lock" are excluded.

        .. WARNING::
           Unline :meth:`export_to_zip`, this method does NOT respect the
           SUBMIT attribute!

        :param destination: Path to zip file to be created; must end in
            ".gdb.zip"
        :type destination: pathlib.Path
        :param overwrite: Whether to overwrite any existing file at
            ``destination``, default False
        :type overwrite: bool
        :param staged_gdb_path: If given, uses this argument as the path to the
            geodatabase to export instead of ``self.gdb_path``, default None
        :type staged_gdb_path: Optional[pathlib.Path]
        :return: Absolute path to the output file
        :rtype: pathlib.Path
        """
        destination: Path = Path(destination)
        mode: Literal["w", "x"] = "w" if overwrite else "x"
        if destination.is_dir():
            raise IsADirectoryError("Argument 'destination' must be a path to a file, not a directory.")
        elif not destination.name.endswith(".gdb.zip"):
            raise ValueError("Argument 'destination' must end in '.gdb.zip'.")

        with ZipFile(destination, mode) as z:
            path: Path
            zipped_folder: Path = Path(destination.stem)
            z.mkdir(destination.stem)
            for path in (staged_gdb_path or self.gdb_path).iterdir():
                if path.is_dir():
                    raise RuntimeError(fr"Unexpectedly encountered a directory ('{path.name}') inside geodatabase directory.")
                elif not path.name.lower().endswith(".lock"):
                    z.write(path, zipped_folder / path.name)

        return destination.absolute()

    def to_shapefiles(self, destination: Path | str, respect_submit: Optional[bool] = None, overwrite: bool = False) -> list[Path]:
        """
        Exports all NG911 feature classes to individual shapefiles.

        :param destination: Path to the output directory
        :type destination: pathlib.Path | str
        :param respect_submit: Whether to only export those features where the
            ``SUBMIT`` attribute is `'Y'`, defaults to ``self._respect_submit``
        :type respect_submit: Optional[bool]
        :param overwrite: Whether to overwrite files in the output directory if
            needed, default False
        :type overwrite: bool
        :return: Paths to the output shapefiles (only the .shp files)
        :rtype: list[pathlib.Path]
        """
        destination: Path = Path(destination)
        respect_submit: bool = self._respect_submit if respect_submit is None else respect_submit
        fc_list: list[str] = []

        with self.required_ds_env_manager:
            fc_list += [str(self.path_to(config.get_feature_class_by_name(fc_name))) for fc_name in arcpy.ListFeatureClasses() if fc_name in config.required_feature_class_names]
        with self.optional_ds_env_manager:
            fc_list += [str(self.path_to(config.get_feature_class_by_name(fc_name))) for fc_name in arcpy.ListFeatureClasses() if fc_name in config.optional_feature_class_names]

        if not destination.is_dir():
            raise ValueError(f"'{destination}' must be a directory.")
        output_dir: Path = destination / self.gdb_path.stem
        if output_dir.is_dir():
            raise ValueError(f"Cannot create directory '{output_dir}' because it already exists.")
        os.mkdir(output_dir)
        self.messenger.addMessage(f"Created directory '{output_dir}'.")

        envs: dict = self._environments.copy()
        envs["overwriteOutput"] = overwrite
        output_list: list[Path] = []
        wc: str | None = f"{config.fields.submit.name} = 'Y'" if respect_submit else None
        with arcpy.EnvManager(**envs):
            for fc_path in fc_list:
                try:
                    result: arcpy.Result = arcpy.conversion.ExportFeatures(
                        in_features=fc_path,
                        # out_features=str(output_dir / Path(fc_path).name) + ".shp",
                        out_features=str(output_dir / Path(fc_path).name),
                        where_clause=wc,
                        # use_field_alias_as_name="NOT_USE_ALIAS"
                    )
                    out_path: str = result.getOutput(0)
                except Exception as ex:
                    exc_text = "".join(format_exception(ex))
                    self.messenger.addWarningMessage(f"Failed to export '{fc_path}':\n{exc_text}")
                else:
                    self.messenger.addMessage(f"Exported shapefile '{out_path}'.")
                    output_list.append(Path(out_path))

        return output_list

    def export_to_zip(self, destination: Path | str, respect_submit: Optional[bool] = None, overwrite: bool = False, add_topology: bool = True) -> Path:
        """
        Exports NG911 feature classes to a temporary geodatabase, zips it, and
        returns the absolute path to the zipped geodatabase.

        .. NOTE::
           Unline :meth:`_copy_to_zip`, this method *does* respect the SUBMIT
           attribute!

        :param destination: Path to zip file to be created; must end in
            ".gdb.zip"
        :type destination: pathlib.Path
        :param respect_submit: Whether to only export features where the SUBMIT
            attribute is 'Y'; if not specified, uses ``self._respect_submit``
        :type respect_submit: Optional[bool]
        :param overwrite: Whether to overwrite any existing file at
            ``destination``, default False
        :type overwrite: bool
        :param add_topology: Whether to add and validate topology rules,
            default True
        :type add_topology: bool
        :return: Absolute path to the output file
        :rtype: pathlib.Path
        """
        destination: Path = Path(destination)
        respect_submit: bool = self._respect_submit if respect_submit is None else respect_submit
        if destination.is_dir():
            raise IsADirectoryError("Argument 'destination' must be a path to a file, not a directory.")
        if not destination.name.endswith(".gdb.zip"):
            raise ValueError("Argument 'destination' must end in '.gdb.zip'.")
        if destination.exists() and not overwrite:
            raise FileExistsError(f"'{destination}' already exists and 'overwrite' is False.")


        # staging_gdb_name: str = f"{destination.name.split('.gdb.zip')[0]}_{time_ns()}.gdb"  # If destination is r"C:\data\output.gdb.zip", this is something like "output_1738609968000656500.gdb"
        # staging_gdb_path: Path = destination.parent / staging_gdb_name
        with TemporaryDirectory() as staging_dir:
            staging_dir_path = Path(staging_dir)
            staging_gdb_path = staging_dir_path / destination.stem  # .stem removes ".zip" but leaves ".gdb"
            gdb_creator = StandardGeodatabaseCreation(str(staging_gdb_path.parent), str(staging_gdb_path.stem))
            sr: arcpy.SpatialReference = arcpy.Describe(config.gdb_info.required_dataset_name).spatialReference
            gdb_creator.create_std_gdb(sr)
            gdb_creator.assign_domain_to_gdb()

            for fc in config.feature_classes.values():
                if arcpy.Exists(fc.name):
                    in_features = str(self.path_to(fc))
                    out_features = str(staging_gdb_path / fc.dataset / fc.name)
                    wc = f"{config.fields.submit.name} = 'Y'" if respect_submit else None
                    arcpy.conversion.ExportFeatures(in_features, out_features, wc, False)

            if add_topology:
                add_topo_respect_submit: bool = self._respect_submit if respect_submit is None else respect_submit
                with NG911Session(staging_gdb_path, add_topo_respect_submit, messenger=self.messenger) as temp_gdb_session:
                    temp_gdb_session.add_and_configure_topologies(True)
                    temp_gdb_session.validate_topologies()

            arcpy.management.Compact(str(staging_gdb_path))  # Removes locks
            try:
                out_path = self._copy_to_zip(destination, overwrite, staged_gdb_path=staging_gdb_path)
            except Exception as ex:
                self.messenger.addErrorMessage("\n".join(format_exception(ex)))
                out_path = None
        return out_path

    @staticmethod
    def _configure_topology(ds_env_manager: arcpy.EnvManager, topology_name: str, rules: Sequence[TopologyRule]) -> tuple[int, int] | None:
        """
        Given an ``arcpy.EnvManager``, topology name, and sequence of rules,
        adds those rules and their members to the topology *topology_name*.

        .. note::

           Parameter *ds_env_manager* is intended to be set to either
           :attr:`required_ds_env_manager` or :attr:`optional_ds_env_manager`.

        :param ds_env_manager: An environment manager with the workspace set to
            the target feature dataset
        :type ds_env_manager: arcpy.EnvManager
        :param topology_name: Name of the topology to configure
        :type topology_name: str
        :param rules: Rules to add to the topology
        :type rules: Sequence[TopologyRule]
        :return: Tuple of numbers of rules and feature classes, respectively,
            added to the topology; ``(0, 0)`` if there is no topology
        :rtype: tuple[int, int]
        """
        added_rules_count: int = 0
        added_members_count: int = 0
        with ds_env_manager:
            if not arcpy.Exists(topology_name):
                return 0, 0
            describe_obj = arcpy.Describe(topology_name)
            current_members: set[NG911FeatureClass] = {config.get_feature_class_by_name(fc_name) for fc_name in describe_obj.featureClassNames}
            _logger.debug(f"Current members of topology '{topology_name}': {', '.join(fc.name for fc in current_members)}")
            for rule in rules:
                for member in set(rule.members) - current_members:
                    _logger.debug(f"Adding member '{member}' to topology '{topology_name}'.")
                    arcpy.management.AddFeatureClassToTopology(topology_name, member.name)
                    current_members.add(member)
                    added_members_count += 1
                rule.add_rule_to_topology(topology_name)
                added_rules_count += 1
        _logger.info(f"Added {added_rules_count} rule(s) and {added_members_count} member(s) to topology '{topology_name}'.")
        return added_rules_count, added_members_count

    def _add_required_ds_topology(self, replace: bool = False) -> bool:
        """
        If :data:`~.topology.topology_config` specifies at least one rule and at least one
        member for a topology in the :term:`required feature dataset`, this method creates a
        topology for that dataset if one does not already exist. It does
        **not** add any rules or members to the new topology.

        The new topology will have the name specified by
        :attr:`topology_config.required_dataset_topology_name <.topology.topology_config.required_dataset_topology_name>`.

        :param replace: Whether to delete and recreate the topology if one
            already exists. Has no effect if one does not already exist or if
            :data:`~.topology.topology_config` specifies no rules or no members for the
            new topology. Default False.
        :type replace: bool
        :return: Whether a new topology was created
        :rtype: bool
        """
        if topology_config.required_dataset_rules and topology_config.required_dataset_members:
            with self.required_ds_env_manager:
                if replace and arcpy.Exists(_rdst):
                    arcpy.management.Delete(_rdst)
                    self.messenger.addMessage(f"Deleted old topology '{_rdst}'.")
                self.messenger.addMessage(f"Adding topology called '{_rdst}' to dataset '{_rds}'.")
                ds_path = self.path_to(_rds)
                arcpy.management.CreateTopology(str(ds_path), _rdst)
            return True
        else:
            return False

    def _add_optional_ds_topology(self, replace: bool = False) -> bool:
        """
        If :data:`~.topology.topology_config` specifies at least one rule and at least one
        member for a topology in the :term:`optional feature dataset`, this method creates a
        topology for that dataset if one does not already exist. It does
        **not** add any rules or members to the new topology.

        The new topology will have the name specified by
        :attr:`topology_config.optional_dataset_topology_name <.topology.topology_config.optional_dataset_topology_name>`.

        :param replace: Whether to delete and recreate the topology if one
            already exists. Has no effect if one does not already exist or if
            :data:`~.topology.topology_config` specifies no rules or no members for the
            new topology. Default False.
        :type replace: bool
        :return: Whether a new topology was created
        :rtype: bool
        """
        if topology_config.optional_dataset_rules and topology_config.optional_dataset_members:
            with self.optional_ds_env_manager:
                if replace and arcpy.Exists(_odst):
                    arcpy.management.Delete(_odst)
                    self.messenger.addMessage(f"Deleted old topology '{_odst}'.")
                ds_path = self.path_to(_ods)
                arcpy.management.CreateTopology(str(ds_path), _odst)
            return True
        else:
            return False

    def add_and_configure_topologies(self, replace: bool = False) -> tuple[bool, bool]:
        """
        Adds topologies and their respective rules and members to the required
        and optional feature datasets.

        :param replace: Whether to delete and recreate each topology that
            already exists. Has no effect for one does not already exist or if
            ``topology_config`` specifies no rules or no members for that
            topology. Default False.
        :type replace: bool
        :return: Whether the required and optional topologies, respectively,
            were modified
        :rtype: tuple[bool, bool]
        """
        added_rdst: bool = self._add_required_ds_topology(replace)
        if added_rdst:
            self.messenger.addMessage(f"Added topology '{_rds}/{_rdst}'.")
            _logger.info(f"Added topology '{_rds}/{_rdst}'.")
        rdst_changes: tuple[int, int] = self._configure_topology(self.required_ds_env_manager, topology_config.required_dataset_topology_name, topology_config.required_dataset_rules)

        added_odst: bool = self._add_optional_ds_topology(replace)
        if added_odst:
            self.messenger.addMessage(f"Added topology '{_ods}/{_odst}'.")
            _logger.info(f"Added topology '{_ods}/{_odst}'.")
        odst_changes: tuple[int, int] = self._configure_topology(self.optional_ds_env_manager, topology_config.optional_dataset_topology_name, topology_config.optional_dataset_rules)

        return any((added_rdst, *rdst_changes)), any((added_odst, *odst_changes))

    def validate_topologies(self) -> tuple[bool, bool]:
        """
        Validates the required and/or optional dataset topologies if each
        exists.

        :return: Whether the required and optional dataset topologies,
            respectively, were validated
        :rtype: tuple[bool, bool]
        """
        if self.required_ds_topology_exists:
            arcpy.management.ValidateTopology(fr"{_rds}\{_rdst}")
            rdst_validated = True
        else:
            rdst_validated = False
        if self.optional_ds_topology_exists:
            arcpy.management.ValidateTopology(fr"{_ods}\{_odst}")
            odst_validated = True
        else:
            odst_validated = False
        return rdst_validated, odst_validated

    def create_standard_feature_class(self, feature_class: NG911FeatureClass, overwrite: bool = False) -> Path:
        """
        Creates a standard feature class based on the specifications in
        ``config.yml``.

        :param feature_class: The feature class to create
        :type feature_class: NG911FeatureClass
        :param overwrite: Whether to overwrite an existing feature class, if
            present; default False
        :return: Path to the newly-created feature class
        :rtype: pathlib.Path
        """

        # Delete existing feature class if appropriate
        fc_path: str = self.str_path_to(feature_class)
        if arcpy.Exists(fc_path):
            if overwrite:
                arcpy.management.Delete(fc_path)
                _logger.info(f"Deleted existing feature class at '{fc_path}'.")
            else:
                raise FileExistsError(f"Parameter 'overwrite' is set to {overwrite}, but a feature class already exists at '{fc_path}'.")

        # Create standard feature class
        try:
            arcpy.management.CreateFeatureclass(
                out_path=self.str_path_to(feature_class.dataset),
                out_name=feature_class.name,
                geometry_type=feature_class.geometry_type,
                has_z="ENABLED",
                has_m="ENABLED"
            )
        except Exception as exc:
            _logger.critical(f"Failed to create standard feature class '{feature_class.name}'.", exc_info=exc)
            self.messenger.addErrorMessage(f"Failed to create standard feature class: '{feature_class.name}'.")
            raise exc
        else:
            _logger.info(f"Created standard feature class: '{feature_class.name}'.")

        # Add standard fields
        try:
            arcpy.management.AddFields(
                in_table=self.str_path_to(feature_class),
                field_description=feature_class.field_value_table_rows
            )
        except Exception as exc:
            _logger.critical(f"Failed to add standard fields to feature class: '{feature_class.name}'.", exc_info=exc)
            self.messenger.addErrorMessage(f"Failed to add standard fields to feature class: '{feature_class.name}'.")
            raise exc
        else:
            _logger.info(f"Added {len(feature_class.fields)} standard field(s) to '{feature_class.name}'.")

        return self.path_to(feature_class)

    def import_esn_polygons(self,
                            source_fc: PathLike[str] | str,
                            field_map: Mapping[NG911Field | str, arcpy.Field | str | None],
                            assign_sequential_nguids: bool = False,
                            esz_nguid_method: NAM = NAM.NULL,
                            convert_nguids: bool = False,
                            esb_to_display_name: bool = False,
                            overwrite: bool = False
    ) -> tuple[Path | None, Path | None, Path | None, Path | None]:
        """
        Given a source feature class with ESN polygons, derives the feature
        classes :ng911fc:`esb_ems_boundary`, :ng911fc:`esb_fire_boundary`,
        :ng911fc:`esb_law_boundary`, and :ng911fc:`esz_boundary`

        :param source_fc: Path to ESN polygon feature class
        :param field_map: Mapping of **standard** fields to **source** fields
        :param assign_sequential_nguids: Whether to assign sequential NGUID
            attributes to the output ESB (but not ESZ) features; default False
        :param esz_nguid_method: Method to use to assign NGUIDs to the output
            ESZ features; default None
        :param convert_nguids: Whether to convert existing NGUIDs from the
            format used in Standards v2.2 to that used in v3; only applies
            to the output :ng911fc:`esz_boundary` feature class and only
            allowed if *esz_nguid_method* is :attr:`NAM.NGUID`;
            default False
        :param esb_to_display_name: Whether to copy ESB attributes
            (:ng911field:`ems`/:ng911field:`fire`/:ng911field:`law`) to the
            :ng911field:`dsplayname` field, default False
        :param overwrite: Whether to overwrite existing ESB and ESZ feature
            classes, default False
        :return: Paths to derived EMS, fire, law enforcement, and ESZ polygon
            feature classes, respectively (replaced with ``None`` for feature
            classes where errors were encountered during processing)
        :rtype: tuple[Path | None, Path | None, Path | None, Path | None]
        """

        #####################################################
        #### Validate arguments and do other preparation ####
        #####################################################

        source_fc: str = source_fc.__fspath__() if isinstance(source_fc, PathLike) else source_fc
        source_fc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(source_fc)}
        # # If the source field objects actually turn out to be of type 'geoprocessing describe field object', ensure that they are converted to arcpy.Field objects; calling convertArcObjectToPythonObject() on an object that is already an instance of arcpy.Field doesn't appear to be a problem
        # source_fc_fields: dict[str, arcpy.Field] = {f.name: convertArcObjectToPythonObject(f) for f in arcpy.ListFields(source_fc)}

        # Ensure type of field_map is dict[NG911Field, arcpy.Field]
        try:
            field_map: dict[NG911Field, arcpy.Field] = {
                standard_field if isinstance(standard_field, NG911Field)
                    else config.get_field_by_name(standard_field):
                source_field if isinstance(source_field, arcpy.Field)
                    else source_fc_fields[cast(str, source_field)]  # cast() to make the type checker happy
                for standard_field, source_field in field_map.items()
                if source_field
            }
        except KeyError as exc:
            raise ValueError(f"Source feature class does not contain a field named '{exc.args[0]}'.") from exc

        # Alias variables for fields; "_f" to indicate "[f]ield"
        nguid_esz_f: NG911Field = config.feature_classes.esz_boundary.unique_id
        esn_f: NG911Field = config.fields.esn
        esz_f: NG911Field = config.fields.esz
        ems_f: NG911Field = config.fields.ems
        fire_f: NG911Field = config.fields.fire
        law_f: NG911Field = config.fields.law
        localid_f: NG911Field = config.fields.local_id
        agencyid_f: NG911Field = config.fields.agency_id

        # If a necessary field is missing, prepare an error message and raise exception
        required_map_fields: set[NG911Field] = {esz_f, ems_f, fire_f, law_f}
        fields_missing_from_map: set[NG911Field] = required_map_fields - set(field_map.keys())
        if fields_missing_from_map:
            missing_fields_str = ', '.join(f"'{x}'" for x in sorted(f.name for f in fields_missing_from_map))
            raise ValueError(f"The following fields must be present in field_map: {missing_fields_str}.")

        # Require an agency ID field if assign_sequential_nguids is True and/or esz_nguid_method is set
        if (assign_sequential_nguids or esz_nguid_method) and agencyid_f not in field_map.keys():
            raise ValueError(f"If 'assign_sequential_nguids' is True and/or 'esz_nguid_method' is provided, a field must be provided for '{agencyid_f.name}'.")

        # Require that esz_nguid_method is NGUID if convert_nguids is True
        if convert_nguids and esz_nguid_method != NAM.NGUID:
            raise ValueError("'convert_nguids' may only be True if 'esz_nguid_method' is 'NGUID'.")

        # Require a local ID field if esz_nguid_method is LOCAL
        if esz_nguid_method == NAM.LOCAL and localid_f not in field_map.keys():
            raise ValueError(f"If 'esz_nguid_method' is 'LOCAL', a field must be provided for '{localid_f.name}'.")

        # Require an NGUID field if esz_nguid_method is COPY
        if esz_nguid_method == NAM.COPY and nguid_esz_f not in field_map.keys():
            raise ValueError(f"If 'esz_nguid_method' is 'COPY', a field must be provided for '{nguid_esz_f.name}'.")

        # Validate source_fc
        source_describe: dict = arcpy.da.Describe(source_fc)
        source_oid: str | None
        source_shape: str | None
        source_geometry_type: str | None
        if not (source_oid := source_describe.get("OIDFieldName")):
            raise ValueError(f"Source ESN feature class must have an OBJECTID (or equivalent) field.")
        if not (source_shape := source_describe.get("shapeFieldName")):
            raise ValueError(f"Source ESN feature class must have a SHAPE (or equivalent) field.")
        if (source_geometry_type := source_describe.get("shapeType")) != "Polygon":
            raise ValueError(f"Source ESN feature class must have 'Polygon' geometry (got '{source_geometry_type}').")

        # Alias variables for feature classes
        esz = config.feature_classes.esz_boundary
        ems = config.feature_classes.esb_ems_boundary
        fire = config.feature_classes.esb_fire_boundary
        law = config.feature_classes.esb_law_boundary

        # Check for existing ESB and ESZ feature classes if overwrite is False
        if not overwrite:
            existing_fcs: list[str] = [fc.name for fc in (esz, ems, fire, law) if arcpy.Exists(self.str_path_to(fc))]
            if existing_fcs:
                raise FileExistsError(f"Parameter 'overwrite' is set to {overwrite}, but the following feature class(es) already exist: {', '.join(existing_fcs)}")

        result_paths: dict[NG911FeatureClass, Path] = {}

        #########################################################################################
        #### Create each standard ESB feature class, dissolve source, map fields, and append ####
        #########################################################################################
        for output_fc, standard_esb_field in zip(
                (ems, fire, law),
                (ems_f, fire_f, law_f)
        ):
            _logger.info(f"Generating feature class '{output_fc.name}'.")
            source_esb_field_input: arcpy.Field = field_map[standard_esb_field]
            # if not isinstance(source_esb_field_input, arcpy.Field):
            #     raise ValueError(f"The fields {', '.join(f.name for f in (ems, fire, law, esz))} may not be set to '{USE_ESB_FIELD_KEYWORD}'.")

            output_standard_fields: set[NG911Field] = {*output_fc.fields.values()}
            source_dissolve_fields: list[arcpy.Field] = []
            for standard_field in {*output_standard_fields, standard_esb_field}:
                if standard_field in {esn_f, esz_f, localid_f, output_fc.unique_id}:
                    continue
                source_field: arcpy.Field | None = field_map.get(standard_field)
                if isinstance(source_field, arcpy.Field):
                    source_dissolve_fields.append(source_field)
            source_dissolve_field_names: list[str] = [f.name for f in source_dissolve_fields]
            out_temp_fc_name: str = arcpy.CreateUniqueName(f"ESZ_Dissolve_{output_fc.name}", "memory")

            # Create blank standard feature class
            out_fc_path: str = str(self.create_standard_feature_class(output_fc, overwrite))

            try:
                # Dissolve source
                _logger.debug(f"Dissolve fields: {', '.join(source_dissolve_field_names)}")
                arcpy.management.Dissolve(
                    in_features=source_fc,
                    out_feature_class=out_temp_fc_name,
                    dissolve_field=source_dissolve_field_names,
                    multi_part=False
                )
                _logger.info(f"Dissolved features; output to '{out_temp_fc_name}'.")

                # Create field map
                fm_log_message_pieces: list[str] = []
                fms = arcpy.FieldMappings()
                fms.addTable(out_fc_path)
                output_arc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(out_fc_path) if not f.required}
                for standard_field in output_standard_fields:
                    source_field: arcpy.Field | None = field_map.get(standard_field)
                    if esb_to_display_name and standard_field == config.fields.dsplayname:
                        source_field = source_esb_field_input
                    elif not source_field:
                        continue
                    fm = arcpy.FieldMap()
                    _logger.debug(f"Building field map:\n\tStandard field: {standard_field}\n\tSource field: {arc_field_details(source_field)}")
                    fm.addInputField(out_fc_path, standard_field.name)
                    fm.addInputField(out_temp_fc_name, source_field.name)
                    fm.outputField = output_arc_fields[standard_field.name]
                    fms.addFieldMap(fm)
                    fm_log_message_pieces.append(f"{source_field.name} -> {standard_field.name}")
                _logger.debug(f"Populating '{output_fc.name}' using field mappings: {fms.exportToString()}")

                # Map and append dissolved features to standard feature class
                append_result: arcpy.Result = arcpy.management.Append(
                    inputs=out_temp_fc_name,
                    target=out_fc_path,
                    schema_type="NO_TEST",
                    field_mapping=fms
                )
            except Exception as exc:
                _logger.error(f"Dissolve and append process failed for '{output_fc.name}'.", exc_info=exc)
                self.messenger.addWarningMessage(f"Dissolve and append process failed for '{output_fc.name}'.")
                arcpy.management.Delete(out_fc_path)
            else:
                _logger.info("Dissolved features appended to standard feature class.")
                self.messenger.addMessage(f"Created and populated {output_fc.name}.")
                result_paths[output_fc] = Path(append_result.getOutput(0))
            finally:
                arcpy.management.Delete(out_temp_fc_name)

        ###############################################
        #### Create and populate ESZ feature class ####
        ###############################################

        # Create feature class
        try:
            out_fc_path: str = str(self.create_standard_feature_class(esz, overwrite))
            self.messenger.addMessage(f"Created {esz.name}.")

            # Build field map
            fms = arcpy.FieldMappings()
            fms.addTable(out_fc_path)
            output_arc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(out_fc_path) if not f.required}
            for standard_field in esz.fields.values():
                source_field: arcpy.Field | None = field_map.get(standard_field)
                if (not source_field) or (esz_nguid_method == NAM.NULL and standard_field == nguid_esz_f):
                    # If no source field was provided, or if the current field is NGUID but the NULL method was selected, omit from field mappings
                    continue
                current_field_message_detail: str = f"\n\tstandard_field.name = '{source_field.name}'\n\tsource_field.name = '{source_field.name}'"
                fm = arcpy.FieldMap()
                try:
                    fm.addInputField(out_fc_path, standard_field.name)
                except Exception as exc:
                    _logger.critical(f"Failed to add standard input field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fm.addInputField(source_fc, source_field.name)
                except Exception as exc:
                    _logger.critical(f"Failed to add source input field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fm.outputField = output_arc_fields[standard_field.name]
                except Exception as exc:
                    _logger.critical("Failed to add output field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fms.addFieldMap(fm)
                except Exception as exc:
                    _logger.critical(f"Failed to add field map to field mappings:{current_field_message_detail}", exc_info=exc)
                    raise exc
            _logger.debug(f"Populating '{esz.name}' using field mappings: {fms.exportToString()}")

            # Append source features to standard feature class
            append_result: arcpy.Result = arcpy.management.Append(
                inputs=source_fc,
                target=out_fc_path,
                schema_type="NO_TEST",
                field_mapping=fms
            )
        except Exception as exc:
            _logger.critical(f"Failed to create and populate '{esz.name}'.", exc_info=exc)
            self.messenger.addErrorMessage(f"Failed to create and populate '{esz.name}'.")
            arcpy.management.Delete(self.str_path_to(esz))
        else:
            _logger.info(f"Populated '{esz.name}'.")
            self.messenger.addMessage(f"Created and populated {esz.name}.")
            result_paths[esz] = Path(append_result.getOutput(0))

        #######################
        #### Assign NGUIDs ####
        #######################
        if assign_sequential_nguids:
            for fc in (ems, fire, law):
                if fc not in result_paths:
                    # Indicates that the generation of fc failed
                    _logger.warning(f"Not assigning NGUIDs to {fc.name} because it wasn't created.")
                    continue
                self.assign_nguids(fc, NAM.SEQUENTIAL)
                _logger.info(f"Assigned NGUIDs to {fc.name}.")
        else:
            _logger.info("Not assigning NGUIDs to ESB feature classes.")

        match (esz_nguid_method, convert_nguids, esz in result_paths):
            case (None, _, _):
                pass
            case (_, _, False):
                self.messenger.addWarningMessage(f"Not assigning NGUIDs to {esz.name} because it wasn't created.")
            case (NAM.NGUID, _, True):
                self.assign_nguids(esz, NAM.NGUID, convert_format=convert_nguids, overwrite=True)
            case (_, True, True):
                self.messenger.addWarningMessage(f"NGUID format conversion cannot be used with method '{esz_nguid_method}'; not assigning NGUIDS to {esz.name}.")
            case (NAM.LOCAL | NAM.SEQUENTIAL, False, True):
                self.assign_nguids(esz, NAM.LOCAL, overwrite=True)
            case (NAM.COPY | NAM.NULL, False, True):
                pass  # No action needed
            case (_, True, _):
                self.messenger.addWarningMessage(f"Invalid NGUID method '{esz_nguid_method}'; not assigning NGUIDS to {esz.name}.")
            case _:
                _logger.critical(f"Unhandled combination of 'esz_nguid_method', 'convert_nguids', 'esz in result_paths': '{esz_nguid_method}', '{convert_nguids}', '{esz in result_paths}'.")
                self.messenger.addWarningMessage(f"Unhandled combination of 'esz_nguid_method', 'convert_nguids', 'esz in result_paths'. This is a bug. Please report to the developers. NGUIDs will not be assigned to {esz.name}; please use the Assign NGUID Field tool as a workaround.")

        ###########################################
        #### Return paths to results (or None) ####
        ###########################################
        return result_paths.get(ems), result_paths.get(fire), result_paths.get(law), result_paths.get(esz)

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.NGUID],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.NGUID],
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.LOCAL],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      overwrite: bool = False,
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.LOCAL],
                      *,
                      overwrite: bool = False,
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.SEQUENTIAL],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.SEQUENTIAL],
                      *,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: NAM,
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int:
        """
        Assigns new NGUIDs to *feature_class* (or, if supplied,
        *feature_class_name*) in this geodatabase using *assign_method*. If
        *assign_method* is ``NGUID``, this method can optionally convert
        existing NGUIDs from the format used in Standards v2.2 to the format
        used in Standards v3.

        Valid arguments for *assign_method* are:

        - ``NGUID``: Updates (and optionally converts) existing NGUID, changing
          the agency ID if the value of that field is inconsistent with the
          existing NGUID
        - ``LOCAL``: Computes NGUID based on the Local_ID field
        - ``SEQUENTIAL``: Computes NGUID based on a counter, starting from 1

        :param feature_class: NG911 standard feature class for which NGUIDs
            will be computed
        :param assign_method: Method to use to compute new NGUIDs
        :param nguid_field_name: Name of the NGUID field; should only be
            provided if *feature_class* is nonstandard; default None
        :param agency_id_field_name: Name of the agency ID field; should only
            be provided if *feature_class* is nonstandard; default None
        :param local_id_field_name: Name of the Local_ID field; should only be
            provided if *feature_class* is nonstandard; default None
        :param feature_class_name: Name of the target feature class in this
            geodatabase if the name is nonstandard; default None
        :param convert_format: Whether to convert existing NGUIDs from the
            format used in Standards v2.2 to that used in v3; only allowed if
            *assign_method* is :attr:`NAM.NGUID`; default False
        :param overwrite: If True, compute and overwrite NGUIDs for all
            features; if False, compute and set NGUIDs only for features where
            the NGUID attribute is null or a blank string; default False
        :param update_local_id_field: Whether update the local ID field if it
            would otherwise be out of sync with the NGUID; **ignored** for
            *assign_method* ``LOCAL``; default True
        :return: Number of features with new NGUIDs assigned
        """

        nguid_field_name: str = nguid_field_name or feature_class.unique_id.name
        agency_id_field_name: str = agency_id_field_name or config.fields.agency_id.name
        local_id_field_name: str = local_id_field_name or config.fields.local_id.name
        target: Self | Path = self.gdb_path / feature_class_name if feature_class_name else self

        if convert_format and assign_method != NAM.NGUID:
            raise ValueError("'convert_format' may only be True when using 'assign_method' of 'NGUID'.")
        # if update_local_id_field and assign_method == NAM.LOCAL:
        #     self.messenger.addWarningMessage("'update_local_id_field is ignored when using 'assign_method' of 'LOCAL'.")

        builder: NGUIDBuilder
        # noinspection PyUnreachableCode
        match assign_method:
            case NAM.NGUID:
                builder = NGUIDFromNGUIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, convert_format, update_local_id_field, messenger=self.messenger)
            case NAM.LOCAL:
                builder = NGUIDFromLocalIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, local_id_field_name, messenger=self.messenger)
            case NAM.SEQUENTIAL:
                builder = SequentialNGUIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, update_local_id_field, local_id_field_name, messenger=self.messenger)
            case NAM.COPY | NAM.NULL:
                return 0  # Do nothing
            case _:
                raise ValueError(f"Unknown assign_method '{assign_method}'.")

        return builder.assign()


@attrs.frozen
class NGUIDBuilder(ABC, _AttrsInstanceWithAllowedFields):
    """
    Abstract base class for NGUID builder classes. There should be one concrete
    subclass for each value of :class:`~NAM` (except for
    :attr:`~NAM.COPY` and :attr:`~NAM.NULL`).
    """
    assign_method: ClassVar[NAM]

    _target: NG911Session | PathLike[str] | str = attrs.field()
    # _target_str: str = attrs.field(init=False, default=attrs.Factory(lambda self: self._target.__fspath__() if isinstance(self._target, PathLike) else str(self._target), True))
    feature_class: NG911FeatureClass = attrs.field(validator=attrs.validators.instance_of(NG911FeatureClass))
    __ng911_allowed_fields__: FrozenList[str] = attrs.field(init=False, default=attrs.Factory(lambda self: FrozenList([f.name for f in arcpy.ListFields(self.target_fc)]), True))
    overwrite: bool = attrs.field(default=False, validator=attrs.validators.instance_of(bool))
    nguid_field_name: str = attrs.field(default=attrs.Factory(lambda self: self.feature_class.unique_id.name, True), validator=validate_field_exists)
    agency_id_field_name: str = attrs.field(default=attrs.Factory(lambda: config.fields.agency_id.name), validator=validate_field_exists)
    convert_format: bool = attrs.field(default=False, validator=attrs.validators.instance_of(bool))

    messenger: GPMessenger = attrs.field(
        kw_only=True,
        default=attrs.Factory(lambda self: self.session.messenger if self.session else FallbackGPMessenger(), True)
    )
    editor: arcpy.da.Editor | DummyEditor = attrs.field(
        kw_only=True,
        default=attrs.Factory(lambda self: self.session.editor if self.session else DummyEditor(), True)
    )
    # session: NG911Session | None = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: self.target if isinstance(self.target, NG911Session) else None, True)
    # )
    # target: Path = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: self.session.path_to(self.feature_class) if self.session else Path(self._target), True)
    # )
    # parse_nguid: Callable[[str], NGUID] = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: NGUID.from_v2_string if self.convert_format else NGUID.from_string, True)
    # )

    @property
    @abstractmethod
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        """Returns the fields used by the ``UpdateCursor``."""
        ...

    @property
    def session(self) -> NG911Session | None:
        """Returns the :class:`NG911Session` passed during initialization if
        one was passed, otherwise returns ``None``."""
        return self._target if isinstance(self._target, NG911Session) else None

    @property
    def target_fc(self) -> Path:
        """Returns the path to the feature class to be updated."""
        return self.session.path_to(self.feature_class) if self.session else Path(self._target)

    @property
    def target_fc_str(self) -> str:
        """Returns the path to the feature class to be updated as a string."""
        return self.target_fc.__fspath__()

    @property
    def parse_nguid(self) -> Callable[[str], NGUID]:
        return NGUID.from_v2_string if self.convert_format else NGUID.from_string

    @property
    def _where_clause(self) -> str:
        """Returns either a where-clause or ``None`` based on the builder's
        :attr:`overwrite` and :attr:`nguid_field_name`. The result can be
        passed to ArcPy functions that take a where-clause."""
        return None if self.overwrite else f"{self.nguid_field_name} IS NULL OR {self.nguid_field_name} = ''"

    @property
    def _update_cursor(self) -> arcpy.da.UpdateCursor:
        return arcpy.da.UpdateCursor(self.target_fc_str, self.cursor_fields, self._where_clause)

    @abstractmethod
    def assign(self) -> int:
        """
        Assigns NGUID attributes to the target feature class.

        :return: The number of features updated
        :rtype: int
        """
        ...


def _validate_local_id_field_if_needed(instance: "NGUIDFromNGUIDBuilder | SequentialNGUIDBuilder", attribute: "attrs.Attribute[Optional[str]]", value: str | None) -> None:
    """
    ``attrs``-compatible validator intended for use specifically with
    subclasses of :class:`NGUIDBuilder` that can write to a local ID field.
    Intended to ensure one of the following:

    - Local ID should **not** be modified and a local ID field name is **not**
      provided
    - Local ID **should** be modified, a local ID field name **is** provided,
      and that field **exists**
    """
    if not isinstance(value, str | None):
        # If type is wrong, raise an error
        raise TypeError("Expected 'value' to be of type str or None.")
    elif not instance.update_local_id_field and value is None:
        # If field is NOT needed and NOT provided, return without an error
        return
    elif not instance.update_local_id_field and value is not None:
        # If field is NOT needed but IS provided, raise an error
        raise TypeError("If 'update_local_id_field' is False, then 'local_id_field_name' must not be specified.")
    elif instance.update_local_id_field and not value:
        # If field IS needed but is NOT provided, raise an error
        # If value is a string, raise a ValueError, otherwise, raise a TypeError
        raise (ValueError if isinstance(value, str) else TypeError)("If 'update_local_id_field' is True, then 'local_id_field_name' must be specified.")
    elif instance.update_local_id_field:
        # If field IS needed and IS provided (implied if an error wasn't raised above), ensure that the field indeed exists
        validate_field_exists(instance, attribute, value)
    else:
        # This shouldn't happen, but if it does, make it clear that it was unexpected
        _logger.fatal("\n\t".join([
            "Case not accounted for in _validate_local_id_field_if_needed.",
            f"instance: {type(instance).__qualname__} = {instance!r}",
            f"attribute: {type(attribute).__qualname__} = {attribute!r}",
            f"value: {type(value).__qualname__} = {value!r}"
        ]))
        raise RuntimeError(
            "Case not accounted for. This is a bug; please submit a crash report to the developers.\n"
            f"Instance: {instance}\n"
            f"Attribute: {attribute}\n"
            f"Value: {value}"
        )


@attrs.frozen
class NGUIDFromNGUIDBuilder(NGUIDBuilder):

    assign_method: ClassVar[NAM] = NAM.NGUID

    update_local_id_field: bool = attrs.field(default=True, validator=attrs.validators.instance_of(bool))
    local_id_field_name: Optional[str] = attrs.field(
        default=attrs.Factory(
            lambda self: config.fields.local_id.name if self.update_local_id_field else None,
            True
        ),
        validator=_validate_local_id_field_if_needed
    )

    @property
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        if self.update_local_id_field:
            return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name
        else:
            return self.nguid_field_name, self.agency_id_field_name

    def assign(self) -> int:
        records_updated: int = 0
        failures: dict[type[Exception], int] = {}
        self.editor.startEditing()
        with self._update_cursor as uc:
            for row in uc:
                nguid, agency_id = row[:2]
                try:
                    old_nguid_object = self.parse_nguid(nguid)
                    new_nguid_object = NGUID(self.feature_class.name, old_nguid_object.local_id, agency_id)
                    new_nguid = str(new_nguid_object)
                    if new_nguid == nguid:
                        continue
                    elif self.update_local_id_field:
                        uc.updateRow((new_nguid, agency_id, new_nguid_object.local_id))
                    else:
                        uc.updateRow((new_nguid, agency_id))
                except Exception as exc:
                    e_type = type(exc)
                    if e_type not in failures:
                        failures[e_type] = 1
                    else:
                        failures[e_type] += 1
                    if failures[e_type] == 1:
                        # Only show the first exception of each type in the GP messages to help the user fix the problem(s) without flooding the messages
                        self.messenger.addWarningMessage(f"Error building NGUID from existing NGUID '{nguid}' and agency ID '{agency_id}':\n{''.join(format_exception(exc))}")
                    if failures[e_type] <= 3:
                        # If there are more than 3 exceptions of any given type, don't flood the log file
                        _logger.warning(f"Error building NGUID from existing NGUID '{nguid}' and agency ID '{agency_id}'.", exc_info=exc)
                else:
                    records_updated += 1
        self.editor.stopEditing(True)

        if failures:
            self.messenger.addWarningMessage(f"NGUID computation failed for {sum(failures.values())} record(s). See messages above for information and/or log file for further details.")

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")
        return records_updated


@attrs.frozen
class NGUIDFromLocalIDBuilder(NGUIDBuilder):

    assign_method: ClassVar[NAM] = NAM.LOCAL

    local_id_field_name: str = attrs.field(
        default=attrs.Factory(lambda: config.fields.local_id.name),
        validator=validate_field_exists
    )

    convert_format: Literal[False] = attrs.field(init=False, default=False, validator=attrs.validators.in_({False}))

    @property
    def cursor_fields(self) -> tuple[str, str, str]:
        return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name

    def assign(self) -> int:
        records_updated: int = 0
        missing_local_id: int = 0
        failures: dict[type[Exception], int] = {}
        self.editor.startEditing()
        with self._update_cursor as uc:
            for nguid, agency_id, local_id in uc:
                if not local_id:
                    missing_local_id += 1
                    continue
                try:
                    new_nguid = str(NGUID(self.feature_class.name, local_id, agency_id))
                    if new_nguid == nguid:
                        continue
                    uc.updateRow((new_nguid, agency_id, local_id))
                except Exception as exc:
                    e_type = type(exc)
                    if e_type not in failures:
                        failures[e_type] = 1
                    else:
                        failures[e_type] += 1
                    if failures[e_type] == 1:
                        # Only show the first exception of each type in the GP messages to help the user fix the problem(s) without flooding the messages
                        self.messenger.addWarningMessage(f"Error building NGUID from agency ID '{agency_id}' and local ID '{local_id}':\n{''.join(format_exception(exc))}")
                    if failures[e_type] <= 3:
                        # If there are more than 3 exceptions of any given type, don't flood the log file
                        _logger.warning(f"Error building NGUID from agency ID '{agency_id}' and local ID '{local_id}'.", exc_info=exc)
                else:
                    records_updated += 1
        self.editor.stopEditing(True)

        if failures:
            self.messenger.addWarningMessage(f"NGUID computation failed for {sum(failures.values())} record(s). See messages above for information and/or log file for further details.")

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")
        return records_updated




@attrs.frozen
class SequentialNGUIDBuilder(NGUIDBuilder):
    assign_method: ClassVar[NAM] = NAM.SEQUENTIAL

    update_local_id_field: bool = attrs.field(default=True, validator=attrs.validators.instance_of(bool))
    local_id_field_name: Optional[str] = attrs.field(
        default=attrs.Factory(
            lambda self: config.fields.local_id.name if self.update_local_id_field else None,
            True
        ),
        validator=_validate_local_id_field_if_needed
    )

    convert_format: Literal[False] = attrs.field(init=False, default=False, validator=attrs.validators.in_({False}))

    @property
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        if self.update_local_id_field:
            return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name
        else:
            return self.nguid_field_name, self.agency_id_field_name

    def assign(self) -> int:
        skip_sequence_numbers: set[int] = set()
        sequence_number: int = 1
        records_updated: int = 0

        if not self.overwrite:
            # If leaving some NGUIDs intact, see if any of them have integers
            # as the local ID part; make sure we skip those numbers to maintain
            # NGUID uniqueness
            with arcpy.da.SearchCursor(self.target_fc_str, self.nguid_field_name) as sc:
                for nguid, in sc:
                    try:
                        nguid_object: NGUID = self.parse_nguid(nguid)
                        local_id_integer: int = int(nguid_object.local_id)
                    except:
                        continue
                    else:
                        skip_sequence_numbers.add(local_id_integer)

        self.editor.startEditing()
        with self._update_cursor as uc:
            for row in uc:
                agency_id = row[1]
                while sequence_number in skip_sequence_numbers:
                    sequence_number += 1
                new_nguid_object = NGUID(self.feature_class.name, str(sequence_number), agency_id)
                if self.update_local_id_field:
                    uc.updateRow((str(new_nguid_object), agency_id, new_nguid_object.local_id))
                else:
                    uc.updateRow((str(new_nguid_object), agency_id))
                records_updated += 1
                sequence_number += 1
        self.editor.stopEditing(True)

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")

        return records_updated


class FallbackGPMessenger(GPMessenger):
    """Provides fallback functionality when no geoprocessing messenger is
    available."""

    def addMessage(self, message: str) -> None:
        print(message)

    def addWarningMessage(self, message: str) -> None:
        print(f"WARNING: {message}")

    def addErrorMessage(self, message: str) -> None:
        print(f"ERROR: {message}")

    def addIDMessage(self, message_type: Literal["ERROR", "INFORMATIVE", "WARNING"], message_ID: int, add_argument1: Optional[str | int | float] = None, add_argument2: Optional[str | int | float] = None) -> None:
        id_message = arcpy.GetIDMessage(message_ID)
        message_args = tuple(x for x in (add_argument1, add_argument2) if x)
        message = id_message % message_args
        # noinspection PyUnreachableCode
        match message_type:
            case "INFORMATIVE":
                self.addMessage(message)
            case "WARNING":
                self.addWarningMessage(message)
            case "ERROR":
                self.addErrorMessage(message)
            case _:
                raise ValueError("Invalid 'message_type'.")

    def addGPMessages(self) -> None:
        pass


__all__ = ["NG911Session", "NGUIDBuilder", "NGUIDFromNGUIDBuilder", "NGUIDFromLocalIDBuilder", "SequentialNGUIDBuilder", "NG911DataFrameAttrs", "GPMessenger", "FallbackGPMessenger", "DummyEditor"]